NestJS Module Provider Controller 역할 구분

NestJS Module Provider Controller 역할 구분

한눈에 보기

Controller는 HTTP 입력을 애플리케이션 명령으로 바꾸고 결과를 HTTP 응답으로 표현한다. Provider는 유스케이스, 저장소, 외부 클라이언트처럼 주입 가능한 역할을 수행한다. Module은 이 객체들을 직접 실행하는 곳이 아니라 어떤 Provider를 만들고 어떤 경계를 외부에 공개할지 선언하는 구성 단위다.

목차

NestJS를 파일 종류로만 나누면 생기는 문제

NestJS CLI로 리소스를 만들면 Controller, Service, Module 파일이 생긴다. 역할을 이해하지 않고 템플릿처럼 사용하면 Service 하나에 모든 로직이 모인다.

@Injectable()
export class OrdersService {
  async create(dto: CreateOrderDto) {
    // DTO 검증
    // 사용자 조회
    // 재고 API 호출
    // 가격 계산
    // 주문 INSERT
    // 결제 API 호출
    // 이메일 발송
    // HTTP 예외 변환
  }
}

Controller는 Service를 한 줄 호출하고, Module은 파일을 배열에 나열한다.

@Controller("orders")
export class OrdersController {
  constructor(
    private readonly ordersService: OrdersService,
  ) {}

  @Post()
  create(@Body() dto: CreateOrderDto) {
    return this.ordersService.create(dto);
  }
}

처음에는 단순하지만 기능이 커지면 문제가 드러난다.

Controller, Service, Module은 폴더 규칙이 아니라 서로 다른 책임을 표현한다.

요청이 처리되는 흐름부터 본다

주문 생성 요청을 예로 들면 역할은 다음처럼 흐른다.

flowchart LR
    H[HTTP Request] --> C[OrdersController]
    C --> U[CreateOrder Use Case]
    U --> R[Order Repository]
    U --> I[Inventory Port]
    U --> P[Payment Port]
    R --> DB[(Database)]
    I --> IA[Inventory API]
    P --> PA[Payment API]
    U --> C
    C --> O[HTTP Response]

Module은 이 실행 흐름 안에서 매 요청마다 호출되는 단계가 아니다. 애플리케이션 시작 시 Nest가 그래프를 구성할 수 있도록 관계를 선언한다.

flowchart TD
    M[OrdersModule] --> C[OrdersController 생성]
    M --> U[CreateOrderService 생성]
    M --> R[PostgresOrderRepository 생성]
    M --> I[HttpInventoryClient 생성]
    U --> R
    U --> I
    C --> U

이 차이를 기억하면 Module 클래스 생성자에 업무 로직을 넣거나 Controller가 Repository를 직접 호출하는 일을 줄일 수 있다.

Controller는 전송 계층의 어댑터다

Controller는 HTTP 요청을 이해한다.

@Controller("orders")
export class OrdersController {
  constructor(
    private readonly createOrder:
      CreateOrderUseCase,
  ) {}

  @Post()
  @HttpCode(201)
  async create(
    @CurrentUser() user: AuthenticatedUser,
    @Body() body: CreateOrderRequest,
  ): Promise<CreateOrderResponse> {
    const result = await this.createOrder.execute({
      customerId: user.id,
      items: body.items.map((item) => ({
        productId: item.productId,
        quantity: item.quantity,
      })),
      idempotencyKey: body.idempotencyKey,
    });

    return {
      orderId: result.orderId,
      status: result.status,
      totalAmount: result.totalAmount.toString(),
    };
  }
}

Controller가 수행한 변환은 두 방향이다.

HTTP Request DTO
→ 애플리케이션 Command

애플리케이션 Result
→ HTTP Response DTO

유스케이스는 @Body(), Request, Response, HTTP status를 몰라도 된다.

Controller에 넣지 않을 로직

// 피하고 싶은 예
@Post()
async create(@Body() body: CreateOrderRequest) {
  const product = await this.database.product.findUnique({
    where: { id: body.productId },
  });

  const total = product.price * body.quantity;

  if (total > 1_000_000) {
    throw new BadRequestException(
      "order amount is too large",
    );
  }

  return this.database.order.create({
    data: { ...body, total },
  });
}

가격 계산과 주문 한도는 HTTP가 아닌 업무 규칙이다. CLI, 메시지 소비자, GraphQL Resolver에서도 같아야 한다. Provider에 둔다.

모든 예외를 Controller에서 catch하지 않는다

try {
  return await this.createOrder.execute(command);
} catch (error) {
  if (error instanceof OutOfStockError) {
    throw new ConflictException();
  }

  throw new InternalServerErrorException();
}

한두 endpoint에서는 가능하지만 반복되면 Exception Filter에서 애플리케이션 오류를 HTTP 표현으로 매핑하는 편이 일관적이다. Controller는 정상 흐름을 읽기 쉽게 유지한다.

얇다는 것은 코드 줄 수가 아니라 책임의 폭이다

DTO 매핑이 길어질 수는 있다. 전송 계층 변환이 명확하다면 Controller의 책임 안이다. 업무 의사결정과 저장소 호출이 들어오기 시작하면 경계를 다시 본다.

Provider는 Service보다 넓은 개념이다

NestJS에서 Provider는 DI 컨테이너가 생성하고 다른 객체에 주입할 수 있는 대상이다. Service는 관습적인 이름일 뿐 Provider의 한 종류다.

Provider
├─ Application Service / Use Case
├─ Repository
├─ External API Client
├─ Factory
├─ Clock
├─ ID Generator
├─ Configuration
└─ Domain Policy Adapter

애플리케이션 유스케이스

업무 흐름을 조정한다.

export interface CreateOrderCommand {
  customerId: string;
  items: readonly {
    productId: string;
    quantity: number;
  }[];
  idempotencyKey: string;
}

export abstract class Clock {
  abstract now(): Date;
}

@Injectable()
export class CreateOrderService
  implements CreateOrderUseCase {
  constructor(
    @Inject(ORDER_REPOSITORY)
    private readonly orders: OrderRepository,
    @Inject(INVENTORY_PORT)
    private readonly inventory: InventoryPort,
    private readonly clock: Clock,
  ) {}

  async execute(
    command: CreateOrderCommand,
  ): Promise<CreateOrderResult> {
    const existing =
      await this.orders.findByIdempotencyKey(
        command.idempotencyKey,
      );

    if (existing) {
      return toResult(existing);
    }

    const reservation =
      await this.inventory.reserve(command.items);

    const order = Order.create({
      customerId: command.customerId,
      reservationId: reservation.id,
      items: command.items,
      createdAt: this.clock.now(),
    });

    await this.orders.save(order);

    return toResult(order);
  }
}

Repository Provider

저장 기술을 캡슐화한다.

export interface OrderRepository {
  findByIdempotencyKey(
    key: string,
  ): Promise<Order | undefined>;

  save(order: Order): Promise<void>;
}
@Injectable()
export class PostgresOrderRepository
  implements OrderRepository {
  constructor(
    private readonly database: DatabaseClient,
  ) {}

  async save(order: Order): Promise<void> {
    await this.database.query(
      `
        INSERT INTO orders (
          id,
          customer_id,
          status,
          created_at
        )
        VALUES ($1, $2, $3, $4)
      `,
      [
        order.id,
        order.customerId,
        order.status,
        order.createdAt,
      ],
    );
  }
}

외부 클라이언트 Provider

@Injectable()
export class HttpInventoryClient
  implements InventoryPort {
  constructor(
    private readonly http: HttpClient,
    private readonly config: InventoryConfig,
  ) {}

  async reserve(
    items: readonly OrderItemInput[],
  ): Promise<InventoryReservation> {
    return this.http.post(
      new URL("/reservations", this.config.baseUrl),
      { items },
      {
        timeoutMs: this.config.timeoutMs,
      },
    );
  }
}

역할마다 클래스를 나눈다고 무조건 좋은 것은 아니다. 변경 이유와 테스트 대역, 재사용 경계가 다를 때 분리한다.

Module은 의존성 그래프와 공개 API를 구성한다

NestJS 공식 문서의 Module 메타데이터는 네 가지 핵심 배열을 가진다.

필드 의미
controllers 이 Module이 생성할 Controller
providers 이 Module 내부에서 생성·주입할 Provider
imports 필요한 Provider를 export하는 다른 Module
exports 다른 Module에 공개할 Provider 또는 Module
@Module({
  imports: [
    DatabaseModule,
    InventoryIntegrationModule,
  ],
  controllers: [
    OrdersController,
  ],
  providers: [
    CreateOrderService,
    {
      provide: CREATE_ORDER_USE_CASE,
      useExisting: CreateOrderService,
    },
    {
      provide: ORDER_REPOSITORY,
      useClass: PostgresOrderRepository,
    },
    {
      provide: Clock,
      useClass: SystemClock,
    },
  ],
  exports: [
    CREATE_ORDER_USE_CASE,
  ],
})
export class OrdersModule {}

Module은 Provider를 캡슐화한다. providers에 있다고 다른 Module에서 자동으로 주입할 수 있는 것이 아니다. exports에 포함하고 소비 Module이 imports에 해당 Module을 추가해야 한다.

@Module({
  imports: [OrdersModule],
  providers: [OrderEventHandler],
})
export class OrderEventsModule {}

Module의 exports는 공개 API와 같다.

export 목록은 경계 선언이다

다른 Module이 정말 사용해야 하는 유스케이스나 계약만 공개한다. 내부 Repository와 외부 Client까지 모두 export하면 구현 세부사항이 애플리케이션 전체에 퍼진다.

기능 Module의 내부와 외부를 나눈다

주문 Module이 다음 Provider를 가진다고 하자.

OrdersModule
├─ OrdersController
├─ CreateOrderService
├─ CancelOrderService
├─ PostgresOrderRepository
├─ OrderNumberGenerator
└─ HttpInventoryClient

외부 Module이 주문 생성과 취소만 필요하다면 유스케이스 계약만 export한다.

@Module({
  controllers: [OrdersController],
  providers: [
    CreateOrderService,
    CancelOrderService,
    PostgresOrderRepository,
    OrderNumberGenerator,
    HttpInventoryClient,
    {
      provide: CREATE_ORDER_USE_CASE,
      useExisting: CreateOrderService,
    },
    {
      provide: CANCEL_ORDER_USE_CASE,
      useExisting: CancelOrderService,
    },
  ],
  exports: [
    CREATE_ORDER_USE_CASE,
    CANCEL_ORDER_USE_CASE,
  ],
})
export class OrdersModule {}

다른 Module이 PostgresOrderRepository를 직접 주입하지 못하면 답답해 보일 수 있다. 그러나 그 제약 덕분에 주문 데이터 변경이 정해진 유스케이스를 통과한다.

Controller는 보통 export하지 않는다

Controller는 Nest가 라우트를 등록하기 위해 Module에 속한다. 다른 Module이 Controller를 직접 주입해 호출하는 구조는 전송 계층과 애플리케이션 경계를 뒤집는다. 재사용할 로직은 Provider로 추출한다.

Entity를 Module API로 착각하지 않는다

도메인 모델 타입은 TypeScript import로 공유할 수 있지만 DI Provider export와는 다른 문제다. Module exports는 런타임 의존성 그래프에 관한 선언이다.

Custom Provider로 구현과 계약을 분리한다

TypeScript interface는 런타임에 사라지므로 Nest가 직접 injection token으로 사용할 수 없다.

export interface OrderRepository {
  save(order: Order): Promise<void>;
}

Symbol token을 정의한다.

export const ORDER_REPOSITORY =
  Symbol("ORDER_REPOSITORY");

구현을 연결한다.

const orderRepositoryProvider = {
  provide: ORDER_REPOSITORY,
  useClass: PostgresOrderRepository,
};

소비자는 토큰으로 주입한다.

@Injectable()
export class CreateOrderService {
  constructor(
    @Inject(ORDER_REPOSITORY)
    private readonly orders: OrderRepository,
  ) {}
}

useClass

환경에 따라 구현을 바꿀 수 있다.

{
  provide: ORDER_REPOSITORY,
  useClass:
    process.env.NODE_ENV === "test"
      ? InMemoryOrderRepository
      : PostgresOrderRepository,
}

설정 분기는 Module 파일에 직접 흩뿌리기보다 검증된 Config와 Dynamic Module factory로 구성하는 편이 낫다.

useValue

불변 설정이나 테스트 대역에 적합하다.

{
  provide: ORDER_LIMIT_POLICY,
  useValue: Object.freeze({
    maxItems: 50,
    maxAmount: 1_000_000,
  }),
}

useFactory

다른 Provider로 생성해야 할 때 사용한다.

{
  provide: INVENTORY_PORT,
  inject: [HttpClient, AppConfig],
  useFactory: (
    http: HttpClient,
    config: AppConfig,
  ): InventoryPort => {
    return new HttpInventoryClient(http, {
      baseUrl: config.inventory.baseUrl,
      timeoutMs:
        config.inventory.timeoutMs,
    });
  },
}

Factory에서 네트워크 호출과 장시간 초기화를 수행하면 부트스트랩이 복잡해진다. 비동기 생성이 꼭 필요하다면 시작 실패, timeout, 종료 정리까지 설계한다.

useExisting

같은 인스턴스를 다른 토큰으로 노출한다.

{
  provide: CREATE_ORDER_USE_CASE,
  useExisting: CreateOrderService,
}

useClass를 두 번 등록하면 같은 클래스의 별도 인스턴스가 만들어질 수 있으므로 별칭 목적에는 useExisting이 맞다.

Module을 너무 작게 또는 크게 만들지 않는다

파일마다 Module을 만들면 import 그래프만 복잡해진다.

OrdersControllerModule
OrdersServiceModule
OrdersRepositoryModule
OrdersMapperModule

반대로 모든 것을 AppModule에 넣으면 캡슐화가 없다.

@Module({
  controllers: [
    OrdersController,
    UsersController,
    PaymentsController,
    ReportsController,
  ],
  providers: [
    // 수십 개 Provider
  ],
})
export class AppModule {}

Module 경계는 함께 변경되고 같은 업무 능력을 제공하는 기능을 기준으로 잡는다.

AppModule
├─ OrdersModule
├─ PaymentsModule
├─ UsersModule
├─ ReportingModule
└─ InfrastructureModule

기준이 될 질문은 다음과 같다.

SharedModule에 정체불명의 helper를 계속 모으지 않는다. 진짜 범용 기술 기능인지, 특정 도메인 책임이 빠져나온 것인지 확인한다.

Global Module과 재수출을 신중히 사용한다

@Global()을 사용하면 소비 Module의 imports 없이 Provider를 주입할 수 있다.

@Global()
@Module({
  providers: [SystemClock],
  exports: [SystemClock],
})
export class CoreModule {}

편리하지만 의존성이 파일에서 보이지 않는다.

@Injectable()
export class CreateOrderService {
  constructor(
    private readonly clock: SystemClock,
  ) {}
}

OrdersModule만 봐서는 SystemClock이 어디서 오는지 알기 어렵다. 공식 문서도 모든 것을 global로 만드는 것을 권장하지 않고 명시적 import로 공개 API를 사용하는 편이 구조와 유지보수에 낫다고 설명한다.

Global 후보는 아주 제한적이다.

DB Repository나 업무 Service는 명시적 Module import를 선호한다.

Module 재수출

@Module({
  imports: [LoggingModule],
  exports: [LoggingModule],
})
export class PlatformModule {}

편의용 facade가 될 수 있지만 여러 단계를 거치면 Provider 출처를 추적하기 어렵다. PlatformModule이 안정적인 기술 계약을 제공할 때만 사용한다.

순환 참조는 책임 경계의 신호다

주문이 결제를 호출하고 결제가 주문 상태를 바꾸면서 두 Module이 서로 import할 수 있다.

OrdersModule → PaymentsModule
PaymentsModule → OrdersModule

forwardRef()로 부트스트랩 오류를 해결할 수 있지만 설계 결합은 남는다.

@Module({
  imports: [
    forwardRef(() => PaymentsModule),
  ],
})
export class OrdersModule {}

먼저 흐름의 소유권을 다시 본다.

상위 오케스트레이터로 분리

CheckoutModule
├─ OrdersModule 사용
└─ PaymentsModule 사용

Checkout 유스케이스가 주문 생성과 결제를 조정한다. Orders와 Payments는 서로 직접 알지 않는다.

이벤트로 역방향 의존 제거

sequenceDiagram
    participant O as OrdersModule
    participant Bus as Event Bus
    participant P as PaymentsModule

    O->>Bus: OrderCreated
    Bus->>P: handle OrderCreated
    P->>Bus: PaymentCompleted
    Bus->>O: handle PaymentCompleted

비동기 이벤트는 eventual consistency, 중복 처리, 실패 복구 비용이 있으므로 단순히 순환 참조를 피하려고 도입하지 않는다.

더 작은 계약 추출

두 Module이 공통으로 필요한 순수 정책이나 포트를 제3의 안정된 Module로 분리할 수 있다. 단, SharedModule로 모든 것을 던지는 것은 경계를 숨길 뿐이다.

forwardRef()는 마지막 수단에 가깝다

프레임워크 해법을 적용하기 전에 양쪽이 서로의 내부 Service를 알아야 하는 이유를 설명할 수 있는지 확인한다.

테스트 경계도 역할에 맞춰 나눈다

Controller 테스트

HTTP 매핑과 전송 계약을 본다.

describe("OrdersController", () => {
  it("maps the request to a command", async () => {
    const useCase = {
      execute: vi.fn().mockResolvedValue({
        orderId: "order-example",
        status: "pending",
        totalAmount: Money.won(12_000),
      }),
    };

    const controller =
      new OrdersController(useCase);

    const response = await controller.create(
      { id: "user-example" },
      {
        items: [
          {
            productId: "product-example",
            quantity: 2,
          },
        ],
        idempotencyKey: "key-example",
      },
    );

    expect(useCase.execute).toHaveBeenCalledWith({
      customerId: "user-example",
      items: [
        {
          productId: "product-example",
          quantity: 2,
        },
      ],
      idempotencyKey: "key-example",
    });

    expect(response.orderId)
      .toBe("order-example");
  });
});

Provider 단위 테스트

Nest TestingModule 없이 생성자에 가짜 계약을 전달할 수 있다.

const service = new CreateOrderService(
  inMemoryOrderRepository,
  fakeInventory,
  fixedClock,
);

프레임워크 컨테이너 없이 테스트할 수 있으면 Provider의 업무 로직이 Nest에 과도하게 묶이지 않았다는 신호다.

Module wiring 테스트

토큰과 export가 올바른지 Nest 컨테이너로 확인한다.

const moduleRef =
  await Test.createTestingModule({
    imports: [OrdersModule],
  })
    .overrideProvider(INVENTORY_PORT)
    .useValue(fakeInventory)
    .compile();

expect(
  moduleRef.get(CREATE_ORDER_USE_CASE),
).toBeDefined();

E2E 테스트

실제 HTTP validation, Guard, Interceptor, Filter와 Controller의 조합을 검증한다. 모든 규칙을 E2E로만 테스트하면 느리고 실패 원인이 불명확해지므로 역할별 테스트와 조합한다.

구조 점검표

마무리

NestJS의 Module, Provider, Controller는 파일을 세 종류로 나누는 규칙이 아니다. 애플리케이션의 전송, 실행, 구성 책임을 분리하는 프레임워크 장치다.

Controller는 HTTP를 애플리케이션 명령과 응답으로 변환하고, Provider는 주입 가능한 유스케이스와 기술 역할을 수행한다. Module은 Provider 관계를 구성하고 exports를 통해 기능의 공개 API를 선언한다.

모든 코드를 Service 하나에 넣거나 모든 Provider를 export하면 프레임워크는 동작해도 경계는 사라진다. 기능 Module의 내부 구현을 숨기고 외부에는 필요한 유스케이스만 공개하면 테스트와 변경 범위, 순환 의존을 관리하기 쉬워진다.

참고 자료

관련 노트